πŸ•ΈοΈ Ada Research Browser

MALWARE_SCANNING_IMPLEMENTATION.md
← Back

Malware Scanning Implementation Guide

Priority: HIGH Effort: 2-4 hours Impact: Prevents malware upload attacks on all WordPress sites


Problem

The codebase scanner found 126 instances of file upload handling without malware scanning across WordPress plugins and custom code.

Risk: - Users can upload malware-infected files - Files are moved to permanent storage without scanning - Malware can then be executed or distributed to other users

Affected: - WordPress core file uploads - Plugin file uploads (Events Calendar, WooCommerce, etc.) - Custom upload handlers


Solution

Implement ClamAV malware scanning at the WordPress level using a must-use plugin (mu-plugin).

This approach: - βœ… Scans ALL file uploads (core + plugins) - βœ… Runs before files reach permanent storage - βœ… Blocks infected files automatically - βœ… Requires no changes to existing plugins - βœ… Works across all WordPress sites on the server


Prerequisites

1. Install ClamAV

# Install ClamAV
sudo apt-get update
sudo apt-get install clamav clamav-daemon

# Start ClamAV daemon
sudo systemctl start clamav-daemon
sudo systemctl enable clamav-daemon

# Update virus definitions
sudo freshclam

# Verify installation
clamscan --version

2. Test ClamAV

# Download EICAR test virus (safe test file)
curl -o /tmp/eicar.com.txt https://secure.eicar.org/eicar.com.txt

# Scan it (should detect EICAR-Test-File)
clamscan /tmp/eicar.com.txt

# Clean up
rm /tmp/eicar.com.txt

Expected output:

/tmp/eicar.com.txt: Eicar-Test-Signature FOUND

Implementation

File: /var/www/html/wordpress/wp-content/mu-plugins/clamav-upload-scanner.php

<?php
/**
 * Plugin Name: ClamAV Upload Scanner
 * Description: Scans all file uploads for malware using ClamAV
 * Version: 1.0.0
 * Author: Quig Enterprises
 */

namespace CxQ\Security\MalwareScanner;

class ClamAV_Upload_Scanner {

    /**
     * @var string Path to clamdscan binary
     */
    private $clamdscan_path = '/usr/bin/clamdscan';

    /**
     * @var bool Whether ClamAV is available
     */
    private $clamav_available = false;

    /**
     * Initialize scanner
     */
    public function __construct() {
        // Check if ClamAV is installed and daemon is running
        $this->clamav_available = $this->check_clamav_availability();

        if (!$this->clamav_available) {
            error_log('[ClamAV Upload Scanner] ClamAV not available - malware scanning disabled');
            return;
        }

        // Hook into file upload process
        add_filter('wp_handle_upload_prefilter', [$this, 'scan_upload'], 10, 1);
        add_filter('wp_check_filetype_and_ext', [$this, 'scan_upload_alt'], 10, 5);

        // Log when scanner is active
        error_log('[ClamAV Upload Scanner] Malware scanning active');
    }

    /**
     * Check if ClamAV is available
     *
     * @return bool
     */
    private function check_clamav_availability() {
        // Check if binary exists
        if (!file_exists($this->clamdscan_path) || !is_executable($this->clamdscan_path)) {
            return false;
        }

        // Test connection to daemon
        $test_output = [];
        $test_return = 0;
        exec($this->clamdscan_path . ' --version 2>&1', $test_output, $test_return);

        return $test_return === 0;
    }

    /**
     * Scan uploaded file for malware
     *
     * @param array $file File upload array
     * @return array Modified file array (with error if malware found)
     */
    public function scan_upload($file) {
        // Skip if ClamAV not available
        if (!$this->clamav_available) {
            return $file;
        }

        // Skip if file upload already has error
        if (!empty($file['error'])) {
            return $file;
        }

        // Get temporary file path
        $tmp_file = isset($file['tmp_name']) ? $file['tmp_name'] : null;

        if (!$tmp_file || !file_exists($tmp_file)) {
            return $file;
        }

        // Scan file with ClamAV
        $scan_result = $this->scan_file($tmp_file);

        if ($scan_result['infected']) {
            // Malware detected - block upload
            $file['error'] = sprintf(
                'Security scan failed: %s. File upload blocked for security reasons.',
                esc_html($scan_result['virus'])
            );

            // Log the blocked upload
            error_log(sprintf(
                '[ClamAV Upload Scanner] BLOCKED MALWARE UPLOAD: %s (detected: %s) from user %d',
                $file['name'] ?? 'unknown',
                $scan_result['virus'],
                get_current_user_id()
            ));

            // Delete the temporary file
            @unlink($tmp_file);

            // Optionally trigger security alert
            do_action('cxq_malware_detected', [
                'file' => $file,
                'scan_result' => $scan_result,
                'user_id' => get_current_user_id(),
                'ip' => $_SERVER['REMOTE_ADDR'] ?? 'unknown'
            ]);

        } else {
            // File is clean - allow upload
            error_log(sprintf(
                '[ClamAV Upload Scanner] CLEAN: %s (scanned by ClamAV)',
                $file['name'] ?? 'unknown'
            ));
        }

        return $file;
    }

    /**
     * Alternative hook for some upload methods
     *
     * @param array $wp_check_filetype_and_ext
     * @param string $file
     * @param string $filename
     * @param array $mimes
     * @param string $real_mime
     * @return array
     */
    public function scan_upload_alt($wp_check_filetype_and_ext, $file, $filename, $mimes, $real_mime) {
        if (!$this->clamav_available) {
            return $wp_check_filetype_and_ext;
        }

        if (file_exists($file)) {
            $scan_result = $this->scan_file($file);

            if ($scan_result['infected']) {
                $wp_check_filetype_and_ext['proper_filename'] = false;
                error_log(sprintf(
                    '[ClamAV Upload Scanner] BLOCKED (alt hook): %s (detected: %s)',
                    $filename,
                    $scan_result['virus']
                ));
            }
        }

        return $wp_check_filetype_and_ext;
    }

    /**
     * Scan a file with ClamAV
     *
     * @param string $file_path Path to file to scan
     * @return array ['infected' => bool, 'virus' => string|null, 'output' => string]
     */
    private function scan_file($file_path) {
        $result = [
            'infected' => false,
            'virus' => null,
            'output' => ''
        ];

        // Build command (use clamdscan for faster scanning via daemon)
        $command = sprintf(
            '%s --no-summary %s 2>&1',
            escapeshellcmd($this->clamdscan_path),
            escapeshellarg($file_path)
        );

        // Execute scan
        $output = [];
        $return_code = 0;
        exec($command, $output, $return_code);

        $result['output'] = implode("\n", $output);

        // Parse result
        // Return codes: 0 = clean, 1 = infected, 2 = error
        if ($return_code === 1) {
            // Infected - extract virus name
            $result['infected'] = true;

            foreach ($output as $line) {
                if (strpos($line, 'FOUND') !== false) {
                    // Extract virus name from line like: "/path/file: Virus.Name FOUND"
                    $parts = explode(':', $line);
                    if (isset($parts[1])) {
                        $virus_part = trim(str_replace('FOUND', '', $parts[1]));
                        $result['virus'] = $virus_part;
                        break;
                    }
                }
            }

            if (!$result['virus']) {
                $result['virus'] = 'Unknown malware';
            }

        } elseif ($return_code === 2) {
            // Error during scan - log but allow upload (fail open)
            error_log(sprintf(
                '[ClamAV Upload Scanner] SCAN ERROR for %s: %s',
                $file_path,
                $result['output']
            ));
        }
        // return_code === 0 means clean

        return $result;
    }
}

// Initialize scanner
new ClamAV_Upload_Scanner();

Option 2: Standalone Scanner Class (For Custom Code)

If you need to scan files in custom code (not WordPress uploads):

<?php
namespace CxQ\Security;

class MalwareScanner {
    public static function scan_file($file_path) {
        $clamdscan = '/usr/bin/clamdscan';

        if (!file_exists($clamdscan)) {
            throw new \Exception('ClamAV not installed');
        }

        $command = sprintf(
            '%s --no-summary %s 2>&1',
            escapeshellcmd($clamdscan),
            escapeshellarg($file_path)
        );

        exec($command, $output, $return_code);

        return [
            'clean' => $return_code === 0,
            'infected' => $return_code === 1,
            'error' => $return_code === 2,
            'output' => implode("\n", $output)
        ];
    }
}

// Usage:
$result = MalwareScanner::scan_file('/tmp/uploaded_file.pdf');
if ($result['infected']) {
    die('Malware detected!');
}

Deployment

Step 1: Deploy to Staging

# Copy mu-plugin to WordPress installation
sudo cp clamav-upload-scanner.php /var/www/html/wordpress/wp-content/mu-plugins/

# Set correct permissions
sudo chown www-data:www-data /var/www/html/wordpress/wp-content/mu-plugins/clamav-upload-scanner.php
sudo chmod 644 /var/www/html/wordpress/wp-content/mu-plugins/clamav-upload-scanner.php

Step 2: Test on Staging

  1. Test clean file upload:
  2. Upload a legitimate PDF or image
  3. Should succeed with log entry: "CLEAN: filename.pdf (scanned by ClamAV)"

  4. Test malware detection: ```bash # Create EICAR test file echo 'X5O!P%@AP[4\PZX54(P^)7CC)7}$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!$H+H*' > /tmp/eicar.txt

# Try to upload via WordPress (should be blocked) ``` - Upload should fail with: "Security scan failed: Eicar-Test-Signature. File upload blocked." - Log entry should show: "BLOCKED MALWARE UPLOAD: eicar.txt (detected: Eicar-Test-Signature)"

  1. Check error logs: bash sudo tail -f /var/log/nginx/error.log | grep "ClamAV Upload Scanner"

Step 3: Deploy to Production

Once tested on staging:

# Use existing deployment script if available, or:
for site in sandbox.quigs.com board.nwlakes.org; do
    echo "Deploying to $site..."
    sudo cp clamav-upload-scanner.php /var/www/html/$site/wp-content/mu-plugins/
    sudo chown www-data:www-data /var/www/html/$site/wp-content/mu-plugins/clamav-upload-scanner.php
done

Monitoring

Check Scanner Status

# Check if ClamAV daemon is running
sudo systemctl status clamav-daemon

# Check recent scans in WordPress logs
sudo grep "ClamAV Upload Scanner" /var/log/nginx/error.log | tail -20

Performance Monitoring

# Monitor ClamAV performance
sudo clamdscan --version

# Check virus database freshness
sudo systemctl status clamav-freshclam

Alert on Malware Detection

Add to WordPress functions.php or mu-plugin:

add_action('cxq_malware_detected', function($data) {
    // Send email alert
    wp_mail(
        'security@quigs.com',
        'SECURITY ALERT: Malware Upload Blocked',
        sprintf(
            "Malware detected: %s\nUser: %d\nIP: %s\nVirus: %s",
            $data['file']['name'],
            $data['user_id'],
            $data['ip'],
            $data['scan_result']['virus']
        )
    );

    // Log to security monitoring system
    error_log('[SECURITY] Malware upload blocked: ' . json_encode($data));
});

Performance Considerations

ClamAV Daemon (clamdscan) vs CLI (clamscan)

Recommendation: Use clamdscan (daemon) for production

File Size Limits

# Check ClamAV limits
grep -E '(MaxFileSize|MaxScanSize|StreamMaxLength)' /etc/clamav/clamd.conf

# Increase if needed (edit /etc/clamav/clamd.conf):
MaxFileSize 100M
MaxScanSize 100M
StreamMaxLength 100M

# Restart daemon after changes
sudo systemctl restart clamav-daemon

Memory Usage

ClamAV daemon uses ~500MB-1GB RAM. Ensure server has sufficient memory.


Maintenance

Update Virus Definitions

ClamAV automatically updates via clamav-freshclam service:

# Check update status
sudo systemctl status clamav-freshclam

# Manual update
sudo freshclam

# Check database version
sudo clamdscan --version

Log Rotation

Add to /etc/logrotate.d/wordpress-clamav:

/var/log/wordpress-clamav/*.log {
    daily
    missingok
    rotate 14
    compress
    delaycompress
    notifempty
    create 0640 www-data www-data
    sharedscripts
}

Troubleshooting

ClamAV Daemon Not Running

# Check status
sudo systemctl status clamav-daemon

# View logs
sudo journalctl -u clamav-daemon -n 50

# Restart
sudo systemctl restart clamav-daemon

Scan Timeout

If large files cause timeouts:

# Increase timeout in /etc/clamav/clamd.conf
ReadTimeout 300

# Restart daemon
sudo systemctl restart clamav-daemon

False Positives

If ClamAV blocks legitimate files:

  1. Verify file is actually clean
  2. Submit false positive to ClamAV: https://www.clamav.net/reports/fp
  3. Temporarily whitelist specific files (use with caution):
// In mu-plugin, add before scanning:
$whitelist_hashes = [
    'abc123...' => 'Known safe file (verified 2026-03-07)'
];

$file_hash = hash_file('sha256', $tmp_file);
if (isset($whitelist_hashes[$file_hash])) {
    return $file; // Skip scan for whitelisted files
}

Success Criteria

βœ… ClamAV daemon running on server βœ… Mu-plugin active on all WordPress sites βœ… Test malware file blocked βœ… Legitimate files upload successfully βœ… Scan results logged to error log βœ… Alerts configured for malware detection βœ… Virus definitions updated daily


Additional Resources


Created: 2026-03-07 Author: Blue Team Status: Ready for implementation Estimated Effort: 2-4 hours Impact: HIGH - Protects all WordPress sites from malware uploads